<?php

declare(strict_types=1);

/**
 * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
 * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
 * SPDX-License-Identifier: AGPL-3.0-or-later
 */

namespace Test\App;

use OC\App\AppManager;
use OC\App\DependencyAnalyzer;
use OC\App\Platform;
use OC\AppConfig;
use OC\Config\ConfigManager;
use OCP\App\AppPathNotFoundException;
use OCP\App\Events\AppDisableEvent;
use OCP\App\Events\AppEnableEvent;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\ICache;
use OCP\ICacheFactory;
use OCP\IConfig;
use OCP\IGroup;
use OCP\IGroupManager;
use OCP\IURLGenerator;
use OCP\IUser;
use OCP\IUserSession;
use OCP\ServerVersion;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
use Test\TestCase;

/**
 * Class AppManagerTest
 *
 * @package Test\App
 */
class AppManagerTest extends TestCase {
	protected function getAppConfig(): AppConfig&MockObject {
		$appConfig = [];
		$config = $this->createMock(AppConfig::class);

		$config->expects($this->any())
			->method('getValue')
			->willReturnCallback(function ($app, $key, $default) use (&$appConfig) {
				return (isset($appConfig[$app]) && isset($appConfig[$app][$key]))
					? $appConfig[$app][$key]
					: $default;
			});
		$config->expects($this->any())
			->method('setValue')
			->willReturnCallback(function ($app, $key, $value) use (&$appConfig): void {
				if (!isset($appConfig[$app])) {
					$appConfig[$app] = [];
				}
				$appConfig[$app][$key] = $value;
			});
		$config->expects($this->any())
			->method('getValues')
			->willReturnCallback(function ($app, $key) use (&$appConfig) {
				if ($app) {
					return $appConfig[$app];
				} else {
					$values = [];
					foreach ($appConfig as $appid => $appData) {
						if (isset($appData[$key])) {
							$values[$appid] = $appData[$key];
						}
					}
					return $values;
				}
			});
		$config->expects($this->any())
			->method('searchValues')
			->willReturnCallback(function ($key, $lazy, $type) use (&$appConfig) {
				$values = [];
				foreach ($appConfig as $appid => $appData) {
					if (isset($appData[$key])) {
						$values[$appid] = $appData[$key];
					}
				}
				return $values;
			});

		return $config;
	}

	protected IUserSession&MockObject $userSession;
	private IConfig&MockObject $config;
	protected IGroupManager&MockObject $groupManager;
	protected AppConfig&MockObject $appConfig;
	protected ICache&MockObject $cache;
	protected ICacheFactory&MockObject $cacheFactory;
	protected IEventDispatcher&MockObject $eventDispatcher;
	protected LoggerInterface&MockObject $logger;
	protected IURLGenerator&MockObject $urlGenerator;
	protected ServerVersion&MockObject $serverVersion;
	protected ConfigManager&MockObject $configManager;

	protected DependencyAnalyzer $dependencyAnalyzer;

	protected AppManager $manager;

	protected function setUp(): void {
		parent::setUp();

		$this->userSession = $this->createMock(IUserSession::class);
		$this->groupManager = $this->createMock(IGroupManager::class);
		$this->config = $this->createMock(IConfig::class);
		$this->appConfig = $this->getAppConfig();
		$this->cacheFactory = $this->createMock(ICacheFactory::class);
		$this->cache = $this->createMock(ICache::class);
		$this->eventDispatcher = $this->createMock(IEventDispatcher::class);
		$this->logger = $this->createMock(LoggerInterface::class);
		$this->urlGenerator = $this->createMock(IURLGenerator::class);
		$this->serverVersion = $this->createMock(ServerVersion::class);
		$this->configManager = $this->createMock(ConfigManager::class);
		$this->dependencyAnalyzer = new DependencyAnalyzer($this->createMock(Platform::class));

		$this->overwriteService(AppConfig::class, $this->appConfig);
		$this->overwriteService(IURLGenerator::class, $this->urlGenerator);

		$this->cacheFactory->expects($this->any())
			->method('createDistributed')
			->with('settings')
			->willReturn($this->cache);

		$this->config
			->method('getSystemValueBool')
			->with('installed', false)
			->willReturn(true);

		$this->manager = new AppManager(
			$this->userSession,
			$this->config,
			$this->groupManager,
			$this->cacheFactory,
			$this->eventDispatcher,
			$this->logger,
			$this->serverVersion,
			$this->configManager,
			$this->dependencyAnalyzer,
		);
	}

	#[DataProvider('dataGetAppIcon')]
	public function testGetAppIcon($callback, ?bool $dark, ?string $expected): void {
		$this->urlGenerator->expects($this->atLeastOnce())
			->method('imagePath')
			->willReturnCallback($callback);

		if ($dark !== null) {
			$this->assertEquals($expected, $this->manager->getAppIcon('test', $dark));
		} else {
			$this->assertEquals($expected, $this->manager->getAppIcon('test'));
		}
	}

	public static function dataGetAppIcon(): array {
		$nothing = function ($appId): void {
			self::assertEquals('test', $appId);
			throw new \RuntimeException();
		};

		$createCallback = function ($workingIcons) {
			return function ($appId, $icon) use ($workingIcons) {
				self::assertEquals('test', $appId);
				if (in_array($icon, $workingIcons)) {
					return '/path/' . $icon;
				}
				throw new \RuntimeException();
			};
		};

		return [
			'does not find anything' => [
				$nothing,
				false,
				null,
			],
			'nothing if request dark but only bright available' => [
				$createCallback(['app.svg']),
				true,
				null,
			],
			'nothing if request bright but only dark available' => [
				$createCallback(['app-dark.svg']),
				false,
				null,
			],
			'bright and only app.svg' => [
				$createCallback(['app.svg']),
				false,
				'/path/app.svg',
			],
			'dark and only app-dark.svg' => [
				$createCallback(['app-dark.svg']),
				true,
				'/path/app-dark.svg',
			],
			'dark only appname -dark.svg' => [
				$createCallback(['test-dark.svg']),
				true,
				'/path/test-dark.svg',
			],
			'bright and only appname.svg' => [
				$createCallback(['test.svg']),
				false,
				'/path/test.svg',
			],
			'priotize custom over default' => [
				$createCallback(['app.svg', 'test.svg']),
				false,
				'/path/test.svg',
			],
			'defaults to bright' => [
				$createCallback(['test-dark.svg', 'test.svg']),
				null,
				'/path/test.svg',
			],
			'no dark icon on default' => [
				$createCallback(['test-dark.svg', 'test.svg', 'app-dark.svg', 'app.svg']),
				false,
				'/path/test.svg',
			],
			'no bright icon on dark' => [
				$createCallback(['test-dark.svg', 'test.svg', 'app-dark.svg', 'app.svg']),
				true,
				'/path/test-dark.svg',
			],
		];
	}

	public function testEnableApp(): void {
		// making sure "files_trashbin" is disabled
		if ($this->manager->isEnabledForUser('files_trashbin')) {
			$this->manager->disableApp('files_trashbin');
		}
		$this->eventDispatcher->expects($this->once())->method('dispatchTyped')->with(new AppEnableEvent('files_trashbin'));

		$this->manager->enableApp('files_trashbin');
		$this->assertEquals('yes', $this->appConfig->getValue('files_trashbin', 'enabled', 'no'));
	}

	public function testDisableApp(): void {
		$this->eventDispatcher->expects($this->once())->method('dispatchTyped')->with(new AppDisableEvent('files_trashbin'));

		$this->manager->disableApp('files_trashbin');
		$this->assertEquals('no', $this->appConfig->getValue('files_trashbin', 'enabled', 'no'));
	}

	public function testNotEnableIfNotInstalled(): void {
		$this->expectException(AppPathNotFoundException::class);
		$this->expectExceptionMessage('Could not find path for some_random_name_which_i_hope_is_not_an_app');
		$this->appConfig->expects(self::never())
			->method('setValue');

		$this->manager->enableApp('some_random_name_which_i_hope_is_not_an_app');
	}

	public function testEnableAppForGroups(): void {
		$group1 = $this->createMock(IGroup::class);
		$group1->method('getGID')
			->willReturn('group1');
		$group2 = $this->createMock(IGroup::class);
		$group2->method('getGID')
			->willReturn('group2');

		$groups = [$group1, $group2];

		/** @var AppManager|MockObject $manager */
		$manager = $this->getMockBuilder(AppManager::class)
			->setConstructorArgs([
				$this->userSession,
				$this->config,
				$this->groupManager,
				$this->cacheFactory,
				$this->eventDispatcher,
				$this->logger,
				$this->serverVersion,
				$this->configManager,
				$this->dependencyAnalyzer,
			])
			->onlyMethods([
				'getAppPath',
			])
			->getMock();

		$manager->expects($this->exactly(2))
			->method('getAppPath')
			->with('test')
			->willReturn('apps/test');

		$this->eventDispatcher->expects($this->once())
			->method('dispatchTyped')
			->with(new AppEnableEvent('test', ['group1', 'group2']));

		$manager->enableAppForGroups('test', $groups);
		$this->assertEquals('["group1","group2"]', $this->appConfig->getValue('test', 'enabled', 'no'));
	}

	public static function dataEnableAppForGroupsAllowedTypes(): array {
		return [
			[[]],
			[[
				'types' => [],
			]],
			[[
				'types' => ['nickvergessen'],
			]],
		];
	}

	/**
	 * @param array $appInfo
	 */
	#[DataProvider('dataEnableAppForGroupsAllowedTypes')]
	public function testEnableAppForGroupsAllowedTypes(array $appInfo): void {
		$group1 = $this->createMock(IGroup::class);
		$group1->method('getGID')
			->willReturn('group1');
		$group2 = $this->createMock(IGroup::class);
		$group2->method('getGID')
			->willReturn('group2');

		$groups = [$group1, $group2];

		/** @var AppManager|MockObject $manager */
		$manager = $this->getMockBuilder(AppManager::class)
			->setConstructorArgs([
				$this->userSession,
				$this->config,
				$this->groupManager,
				$this->cacheFactory,
				$this->eventDispatcher,
				$this->logger,
				$this->serverVersion,
				$this->configManager,
				$this->dependencyAnalyzer,
			])
			->onlyMethods([
				'getAppPath',
				'getAppInfo',
			])
			->getMock();

		$manager->expects($this->once())
			->method('getAppPath')
			->with('test')
			->willReturn('');

		$manager->expects($this->once())
			->method('getAppInfo')
			->with('test')
			->willReturn($appInfo);

		$this->eventDispatcher->expects($this->once())->method('dispatchTyped')->with(new AppEnableEvent('test', ['group1', 'group2']));

		$manager->enableAppForGroups('test', $groups);
		$this->assertEquals('["group1","group2"]', $this->appConfig->getValue('test', 'enabled', 'no'));
	}

	public static function dataEnableAppForGroupsForbiddenTypes(): array {
		return [
			['filesystem'],
			['prelogin'],
			['authentication'],
			['logging'],
			['prevent_group_restriction'],
		];
	}

	/**
	 *
	 * @param string $type
	 *
	 */
	#[DataProvider('dataEnableAppForGroupsForbiddenTypes')]
	public function testEnableAppForGroupsForbiddenTypes($type): void {
		$this->expectException(\Exception::class);
		$this->expectExceptionMessage('test can\'t be enabled for groups.');

		$group1 = $this->createMock(IGroup::class);
		$group1->method('getGID')
			->willReturn('group1');
		$group2 = $this->createMock(IGroup::class);
		$group2->method('getGID')
			->willReturn('group2');

		$groups = [$group1, $group2];

		/** @var AppManager|MockObject $manager */
		$manager = $this->getMockBuilder(AppManager::class)
			->setConstructorArgs([
				$this->userSession,
				$this->config,
				$this->groupManager,
				$this->cacheFactory,
				$this->eventDispatcher,
				$this->logger,
				$this->serverVersion,
				$this->configManager,
				$this->dependencyAnalyzer,
			])
			->onlyMethods([
				'getAppPath',
				'getAppInfo',
			])
			->getMock();

		$manager->expects($this->once())
			->method('getAppPath')
			->with('test')
			->willReturn('');

		$manager->expects($this->once())
			->method('getAppInfo')
			->with('test')
			->willReturn([
				'types' => [$type],
			]);

		$this->eventDispatcher->expects($this->never())->method('dispatchTyped')->with(new AppEnableEvent('test', ['group1', 'group2']));

		$manager->enableAppForGroups('test', $groups);
	}

	public function testIsInstalledEnabled(): void {
		$this->appConfig->setValue('test', 'enabled', 'yes');
		$this->assertTrue($this->manager->isEnabledForAnyone('test'));
	}

	public function testIsInstalledDisabled(): void {
		$this->appConfig->setValue('test', 'enabled', 'no');
		$this->assertFalse($this->manager->isEnabledForAnyone('test'));
	}

	public function testIsInstalledEnabledForGroups(): void {
		$this->appConfig->setValue('test', 'enabled', '["foo"]');
		$this->assertTrue($this->manager->isEnabledForAnyone('test'));
	}

	private function newUser($uid) {
		$user = $this->createMock(IUser::class);
		$user->method('getUID')
			->willReturn($uid);

		return $user;
	}

	public function testIsEnabledForUserEnabled(): void {
		$this->appConfig->setValue('test', 'enabled', 'yes');
		$user = $this->newUser('user1');
		$this->assertTrue($this->manager->isEnabledForUser('test', $user));
	}

	public function testIsEnabledForUserDisabled(): void {
		$this->appConfig->setValue('test', 'enabled', 'no');
		$user = $this->newUser('user1');
		$this->assertFalse($this->manager->isEnabledForUser('test', $user));
	}

	public function testGetAppPath(): void {
		$this->assertEquals(\OC::$SERVERROOT . '/apps/files', $this->manager->getAppPath('files'));
	}

	public function testGetAppPathSymlink(): void {
		$fakeAppDirname = sha1(uniqid('test', true));
		$fakeAppPath = sys_get_temp_dir() . '/' . $fakeAppDirname;
		$fakeAppLink = \OC::$SERVERROOT . '/' . $fakeAppDirname;

		mkdir($fakeAppPath);
		if (symlink($fakeAppPath, $fakeAppLink) === false) {
			$this->markTestSkipped('Failed to create symlink');
		}

		// Use the symlink as the app path
		\OC::$APPSROOTS[] = [
			'path' => $fakeAppLink,
			'url' => \OC::$WEBROOT . '/' . $fakeAppDirname,
			'writable' => false,
		];

		$fakeTestAppPath = $fakeAppPath . '/' . 'test_test_app';
		mkdir($fakeTestAppPath);

		$generatedAppPath = $this->manager->getAppPath('test_test_app');

		rmdir($fakeTestAppPath);
		unlink($fakeAppLink);
		rmdir($fakeAppPath);

		$this->assertEquals($fakeAppLink . '/test_test_app', $generatedAppPath);
	}

	public function testGetAppPathFail(): void {
		$this->expectException(AppPathNotFoundException::class);
		$this->manager->getAppPath('testnotexisting');
	}

	public function testIsEnabledForUserEnabledForGroup(): void {
		$user = $this->newUser('user1');
		$this->groupManager->expects($this->once())
			->method('getUserGroupIds')
			->with($user)
			->willReturn(['foo', 'bar']);

		$this->appConfig->setValue('test', 'enabled', '["foo"]');
		$this->assertTrue($this->manager->isEnabledForUser('test', $user));
	}

	public function testIsEnabledForUserDisabledForGroup(): void {
		$user = $this->newUser('user1');
		$this->groupManager->expects($this->once())
			->method('getUserGroupIds')
			->with($user)
			->willReturn(['bar']);

		$this->appConfig->setValue('test', 'enabled', '["foo"]');
		$this->assertFalse($this->manager->isEnabledForUser('test', $user));
	}

	public function testIsEnabledForUserLoggedOut(): void {
		$this->appConfig->setValue('test', 'enabled', '["foo"]');
		$this->assertFalse($this->manager->isEnabledForUser('test'));
	}

	public function testIsEnabledForUserLoggedIn(): void {
		$user = $this->newUser('user1');

		$this->userSession->expects($this->once())
			->method('getUser')
			->willReturn($user);
		$this->groupManager->expects($this->once())
			->method('getUserGroupIds')
			->with($user)
			->willReturn(['foo', 'bar']);

		$this->appConfig->setValue('test', 'enabled', '["foo"]');
		$this->assertTrue($this->manager->isEnabledForUser('test'));
	}

	public function testGetEnabledApps(): void {
		$this->appConfig->setValue('test1', 'enabled', 'yes');
		$this->appConfig->setValue('test2', 'enabled', 'no');
		$this->appConfig->setValue('test3', 'enabled', '["foo"]');
		$apps = [
			'cloud_federation_api',
			'dav',
			'federatedfilesharing',
			'files',
			'lookup_server_connector',
			'oauth2',
			'profile',
			'provisioning_api',
			'settings',
			'test1',
			'test3',
			'theming',
			'twofactor_backupcodes',
			'viewer',
			'workflowengine',
		];
		$this->assertEquals($apps, $this->manager->getEnabledApps());
	}

	public function testGetAppsForUser(): void {
		$user = $this->newUser('user1');
		$this->groupManager->expects($this->any())
			->method('getUserGroupIds')
			->with($user)
			->willReturn(['foo', 'bar']);

		$this->appConfig->setValue('test1', 'enabled', 'yes');
		$this->appConfig->setValue('test2', 'enabled', 'no');
		$this->appConfig->setValue('test3', 'enabled', '["foo"]');
		$this->appConfig->setValue('test4', 'enabled', '["asd"]');
		$enabled = [
			'cloud_federation_api',
			'dav',
			'federatedfilesharing',
			'files',
			'lookup_server_connector',
			'oauth2',
			'profile',
			'provisioning_api',
			'settings',
			'test1',
			'test3',
			'theming',
			'twofactor_backupcodes',
			'viewer',
			'workflowengine',
		];
		$this->assertEquals($enabled, $this->manager->getEnabledAppsForUser($user));
	}

	public function testGetAppsNeedingUpgrade(): void {
		/** @var AppManager&MockObject $manager */
		$manager = $this->getMockBuilder(AppManager::class)
			->setConstructorArgs([
				$this->userSession,
				$this->config,
				$this->groupManager,
				$this->cacheFactory,
				$this->eventDispatcher,
				$this->logger,
				$this->serverVersion,
				$this->configManager,
				$this->dependencyAnalyzer,
			])
			->onlyMethods(['getAppInfo'])
			->getMock();

		$appInfos = [
			'cloud_federation_api' => ['id' => 'cloud_federation_api'],
			'dav' => ['id' => 'dav'],
			'files' => ['id' => 'files'],
			'federatedfilesharing' => ['id' => 'federatedfilesharing'],
			'profile' => ['id' => 'profile'],
			'provisioning_api' => ['id' => 'provisioning_api'],
			'lookup_server_connector' => ['id' => 'lookup_server_connector'],
			'test1' => ['id' => 'test1', 'version' => '1.0.1', 'requiremax' => '9.0.0'],
			'test2' => ['id' => 'test2', 'version' => '1.0.0', 'requiremin' => '8.2.0'],
			'test3' => ['id' => 'test3', 'version' => '1.2.4', 'requiremin' => '9.0.0'],
			'test4' => ['id' => 'test4', 'version' => '3.0.0', 'requiremin' => '8.1.0'],
			'testnoversion' => ['id' => 'testnoversion', 'requiremin' => '8.2.0'],
			'settings' => ['id' => 'settings'],
			'theming' => ['id' => 'theming'],
			'twofactor_backupcodes' => ['id' => 'twofactor_backupcodes'],
			'viewer' => ['id' => 'viewer'],
			'workflowengine' => ['id' => 'workflowengine'],
			'oauth2' => ['id' => 'oauth2'],
		];

		$manager->expects($this->any())
			->method('getAppInfo')
			->willReturnCallback(
				function ($appId) use ($appInfos) {
					return $appInfos[$appId];
				}
			);

		$this->appConfig->setValue('test1', 'enabled', 'yes');
		$this->appConfig->setValue('test1', 'installed_version', '1.0.0');
		$this->appConfig->setValue('test2', 'enabled', 'yes');
		$this->appConfig->setValue('test2', 'installed_version', '1.0.0');
		$this->appConfig->setValue('test3', 'enabled', 'yes');
		$this->appConfig->setValue('test3', 'installed_version', '1.0.0');
		$this->appConfig->setValue('test4', 'enabled', 'yes');
		$this->appConfig->setValue('test4', 'installed_version', '2.4.0');

		$apps = $manager->getAppsNeedingUpgrade('8.2.0');

		$this->assertCount(2, $apps);
		$this->assertEquals('test1', $apps[0]['id']);
		$this->assertEquals('test4', $apps[1]['id']);
	}

	public function testGetIncompatibleApps(): void {
		/** @var AppManager|MockObject $manager */
		$manager = $this->getMockBuilder(AppManager::class)
			->setConstructorArgs([
				$this->userSession,
				$this->config,
				$this->groupManager,
				$this->cacheFactory,
				$this->eventDispatcher,
				$this->logger,
				$this->serverVersion,
				$this->configManager,
				$this->dependencyAnalyzer,
			])
			->onlyMethods(['getAppInfo'])
			->getMock();

		$appInfos = [
			'cloud_federation_api' => ['id' => 'cloud_federation_api'],
			'dav' => ['id' => 'dav'],
			'files' => ['id' => 'files'],
			'federatedfilesharing' => ['id' => 'federatedfilesharing'],
			'profile' => ['id' => 'profile'],
			'provisioning_api' => ['id' => 'provisioning_api'],
			'lookup_server_connector' => ['id' => 'lookup_server_connector'],
			'test1' => ['id' => 'test1', 'version' => '1.0.1', 'requiremax' => '8.0.0'],
			'test2' => ['id' => 'test2', 'version' => '1.0.0', 'requiremin' => '8.2.0'],
			'test3' => ['id' => 'test3', 'version' => '1.2.4', 'requiremin' => '9.0.0'],
			'settings' => ['id' => 'settings'],
			'testnoversion' => ['id' => 'testnoversion', 'requiremin' => '8.2.0'],
			'theming' => ['id' => 'theming'],
			'twofactor_backupcodes' => ['id' => 'twofactor_backupcodes'],
			'workflowengine' => ['id' => 'workflowengine'],
			'oauth2' => ['id' => 'oauth2'],
			'viewer' => ['id' => 'viewer'],
		];

		$manager->expects($this->any())
			->method('getAppInfo')
			->willReturnCallback(
				function ($appId) use ($appInfos) {
					return $appInfos[$appId];
				}
			);

		$this->appConfig->setValue('test1', 'enabled', 'yes');
		$this->appConfig->setValue('test2', 'enabled', 'yes');
		$this->appConfig->setValue('test3', 'enabled', 'yes');

		$apps = $manager->getIncompatibleApps('8.2.0');

		$this->assertCount(2, $apps);
		$this->assertEquals('test1', $apps[0]['id']);
		$this->assertEquals('test3', $apps[1]['id']);
	}

	public function testGetEnabledAppsForGroup(): void {
		$group = $this->createMock(IGroup::class);
		$group->expects($this->any())
			->method('getGID')
			->willReturn('foo');

		$this->appConfig->setValue('test1', 'enabled', 'yes');
		$this->appConfig->setValue('test2', 'enabled', 'no');
		$this->appConfig->setValue('test3', 'enabled', '["foo"]');
		$this->appConfig->setValue('test4', 'enabled', '["asd"]');
		$enabled = [
			'cloud_federation_api',
			'dav',
			'federatedfilesharing',
			'files',
			'lookup_server_connector',
			'oauth2',
			'profile',
			'provisioning_api',
			'settings',
			'test1',
			'test3',
			'theming',
			'twofactor_backupcodes',
			'viewer',
			'workflowengine',
		];
		$this->assertEquals($enabled, $this->manager->getEnabledAppsForGroup($group));
	}

	public function testGetAppRestriction(): void {
		$this->appConfig->setValue('test1', 'enabled', 'yes');
		$this->appConfig->setValue('test2', 'enabled', 'no');
		$this->appConfig->setValue('test3', 'enabled', '["foo"]');

		$this->assertEquals([], $this->manager->getAppRestriction('test1'));
		$this->assertEquals([], $this->manager->getAppRestriction('test2'));
		$this->assertEquals(['foo'], $this->manager->getAppRestriction('test3'));
	}

	public static function isBackendRequiredDataProvider(): array {
		return [
			// backend available
			[
				'caldav',
				['app1' => ['caldav']],
				true,
			],
			[
				'caldav',
				['app1' => [], 'app2' => ['foo'], 'app3' => ['caldav']],
				true,
			],
			// backend not available
			[
				'caldav',
				['app3' => [], 'app1' => ['foo'], 'app2' => ['bar', 'baz']],
				false,
			],
			// no app available
			[
				'caldav',
				[],
				false,
			],
		];
	}

	#[DataProvider('isBackendRequiredDataProvider')]
	public function testIsBackendRequired(
		string $backend,
		array $appBackends,
		bool $expected,
	): void {
		$appInfoData = array_map(
			static fn (array $backends) => ['dependencies' => ['backend' => $backends]],
			$appBackends,
		);

		$reflection = new \ReflectionClass($this->manager);
		$property = $reflection->getProperty('appInfos');
		$property->setValue($this->manager, $appInfoData);

		$this->assertEquals($expected, $this->manager->isBackendRequired($backend));
	}

	public function testGetAppVersion() {
		$manager = $this->getMockBuilder(AppManager::class)
			->setConstructorArgs([
				$this->userSession,
				$this->config,
				$this->groupManager,
				$this->cacheFactory,
				$this->eventDispatcher,
				$this->logger,
				$this->serverVersion,
				$this->configManager,
				$this->dependencyAnalyzer,
			])
			->onlyMethods([
				'getAppInfo',
			])
			->getMock();

		$manager->expects(self::once())
			->method('getAppInfo')
			->with('myapp')
			->willReturn(['version' => '99.99.99-rc.99']);

		$this->serverVersion
			->expects(self::never())
			->method('getVersionString');

		$this->assertEquals(
			'99.99.99-rc.99',
			$manager->getAppVersion('myapp'),
		);
	}

	public function testGetAppVersionCore() {
		$manager = $this->getMockBuilder(AppManager::class)
			->setConstructorArgs([
				$this->userSession,
				$this->config,
				$this->groupManager,
				$this->cacheFactory,
				$this->eventDispatcher,
				$this->logger,
				$this->serverVersion,
				$this->configManager,
				$this->dependencyAnalyzer,
			])
			->onlyMethods([
				'getAppInfo',
			])
			->getMock();

		$manager->expects(self::never())
			->method('getAppInfo');

		$this->serverVersion
			->expects(self::once())
			->method('getVersionString')
			->willReturn('1.2.3-beta.4');

		$this->assertEquals(
			'1.2.3-beta.4',
			$manager->getAppVersion('core'),
		);
	}

	public function testGetAppVersionUnknown() {
		$manager = $this->getMockBuilder(AppManager::class)
			->setConstructorArgs([
				$this->userSession,
				$this->config,
				$this->groupManager,
				$this->cacheFactory,
				$this->eventDispatcher,
				$this->logger,
				$this->serverVersion,
				$this->configManager,
				$this->dependencyAnalyzer,
			])
			->onlyMethods([
				'getAppInfo',
			])
			->getMock();

		$manager->expects(self::once())
			->method('getAppInfo')
			->with('unknown')
			->willReturn(null);

		$this->serverVersion
			->expects(self::never())
			->method('getVersionString');

		$this->assertEquals(
			'0',
			$manager->getAppVersion('unknown'),
		);
	}

	public static function dataCleanAppId(): array {
		return [
			['simple', 'simple'],
			['UPPERCASEa', 'a'],
			['MixEdCaSe', 'ixdae'],
			['007startwithdigit', 'startwithdigit'],
			['0-numb3rs-4ll0w3d-1n-m1ddle-0', 'numb3rs-4ll0w3d-1n-m1ddle-0'],
			['hyphen-and_underscore_allowed', 'hyphen-and_underscore_allowed'],
			['_but-not-at-the-end_', 'but-not-at-the-end'],
			['-but-not-at-the-end-', 'but-not-at-the-end'],
			['--_but-not-at-the-end___', 'but-not-at-the-end'],
			[' also remove all spaces', 'alsoremoveallspaces'],
			['a«"«»()@+-/*=%\{}…~|&œ—<>[]^±_−÷×≠‰A', 'a-_'],
		];
	}

	#[DataProvider('dataCleanAppId')]
	public function testCleanAppId(string $inputString, string $appid): void {
		$this->assertEquals($appid, $this->manager->cleanAppId($inputString));
	}
}
